C++ Q&A

Menu Tips in an MFC App

Paul DiLascia

Code download available at: CQA0311.exe (193 KB)
Browse the Code Online

Q I'm working on a project that has a long history. When we started, we implemented our own popup menus for various reasons. When tooltips came along, we added this functionality to our menus so that tooltips appear when the user holds the mouse over a menu item. This is important to our users because it explains why a menu item is disabled. As our users became more familiar with the world of Windows® they wanted to have menus that looked like the standard menus. Currently we use CMenu, but we have lost our excellent menu tooltips. How can we implement these in MFC?

Q I'm working on a project that has a long history. When we started, we implemented our own popup menus for various reasons. When tooltips came along, we added this functionality to our menus so that tooltips appear when the user holds the mouse over a menu item. This is important to our users because it explains why a menu item is disabled. As our users became more familiar with the world of Windows® they wanted to have menus that looked like the standard menus. Currently we use CMenu, but we have lost our excellent menu tooltips. How can we implement these in MFC?

Joakim Fagerli

A What a great idea! Figure 1 is the picture worth a thousand words. It shows a little program I wrote, MenuTips, that implements menu tips for any MFC app. Menu tips are especially cool because they eliminate one reason for having a status bar. Even without the status bar, you can still see what each command does. More important, the tip appears next to the menu item where it's more obvious. With today's giant screens, many users don't even realize menu prompts appear in the status bar—it's too far away to notice.

A What a great idea! Figure 1 is the picture worth a thousand words. It shows a little program I wrote, MenuTips, that implements menu tips for any MFC app. Menu tips are especially cool because they eliminate one reason for having a status bar. Even without the status bar, you can still see what each command does. More important, the tip appears next to the menu item where it's more obvious. With today's giant screens, many users don't even realize menu prompts appear in the status bar—it's too far away to notice.

Figure 1 MenuTips

Figure 1** MenuTips **

I implemented the menu tips in a class called CMenuTipManager. To implement menu tips in your app, just add a CMenuTipManager to your main window class and call Install when your frame is created:

// in CMainFrame::OnCreate(...)
m_menuTipManager.Install(this);

That's all there is. Now whenever the user highlights a menu item for longer than a second or so, the menu tip manager displays a tip with the command prompt, as shown in Figure 1. CMenuTipManager gets the prompt from your program's string table—the same place MFC looks for status line prompts.

CMenuTipManager uses my world-famous-by-now subclassing class, CSubclassWnd, to trap WM_MENUSELECT messages sent to the main window. As the user highlights different menu items in the main menu, system menu, or any popup menu, Windows sends the owning window a WM_MENUSELECT for each new menu item highlighted. This is your chance in life to provide feedback or do whatever else you want to do. MFC's CFrameWnd::OnMenuSelect handles WM_MENUSELECT to display the command prompt in the status bar. CMenuTipManager traps the same message to display menu tips. Figure 2 shows the relevant code.

Figure 2 MenuTipper

MenuTipper.h

///////////////////////////////////////////////////////////////
// MSDN Magazine — November 2003
// If this code works, it was written by Paul DiLascia.
// If not, I don't know who wrote it.
// Compiles with Visual Studio .NET on Windows XP. Tab size=3.
//
#pragma once
#include "PupText.h"
#include "subclass.h"

//////////////////
// Implement menu tips for any MFC main window. To use:
//
// - instantiate CMenuTipManager in your CMainFrm
// - call Install
// - implement prompt strings the normal way: as resource 
//   strings w/ID=command ID.
//
class CMenuTipManager : public CSubclassWnd {
protected:
   CPopupText m_wndTip;    // home-grown "tooltip"
   BOOL m_bMouseSelect;    // whether menu invoked by mouse
   BOOL m_bSticky;         // after first tip appears, show 
                           // rest immediately

public:
   int m_iDelay;           // tooltip delay: you can change

   CMenuTipManager() : m_iDelay(1000), m_bSticky(FALSE) { }
   ~CMenuTipManager() { }

   // call this to install tips
   void Install(CWnd* pWnd) { HookWindow(pWnd); }

   // Useful helpers to get window/rect of current active menu
   static CWnd* GetRunningMenuWnd();
   static void  GetRunningMenuRect(CRect& rcMenu);
   CRect GetMenuTipRect(HMENU hmenu, UINT nID);

   // Useful helper to get the prompt string for a command ID.
   // Like CFrameWnd::GetMessageString, but you don't need a
   // frame wnd.
   static CString GetResCommandPrompt(UINT nID);

   // Get the prompt for given command ID
   virtual CString OnGetCommandPrompt(UINT nID)
   {
      return GetResCommandPrompt(nID);
   }

   // hook fn to trap main window's messages
   virtual LRESULT WindowProc(UINT msg, WPARAM wp, LPARAM lp);

   // Call these handlers from your main window
   void OnMenuSelect(UINT nItemID, UINT nFlags, HMENU hMenu);
   void OnEnterIdle(UINT nWhy, HWND hwndWho);
};

Figure 2 MenuTipper

MenuTipper.cpp

///////////////////////////////////////////////////////////////
// MSDN Magazine — November 2003
// If this code works, it was written by Paul DiLascia.
// If not, I don't know who wrote it.
// Compiles with Visual Studio .NET on Windows XP. Tab size=3.
//
#include "stdafx.h"
#include "MenuTipper.h"
#include <afxpriv.h> // for AfxLoadString

//////////////////
// This is more or less copied from 
// CFrameWnd::GetMessageString, but I want a static function 
// that doesn't require a frame window.
//
CString CMenuTipManager::GetResCommandPrompt(UINT nID)
{
   // load appropriate string
   CString s;
   if (s.LoadString(nID)) {
      LPTSTR lpsz = s.GetBuffer(255);
      // first newline terminates prompt
      lpsz = _tcschr(lpsz, '\n');
      if (lpsz != NULL)
         *lpsz = '\0';
      s.ReleaseBuffer();
   }
   return s;
}

//////////////////
// Override CSubclassWnd::WindowProc to hook messages on behalf 
// of main window.
//
LRESULT CMenuTipManager::WindowProc(UINT msg, WPARAM wp, LPARAM lp)
{
   if (msg==WM_MENUSELECT) {
      OnMenuSelect(LOWORD(wp), HIWORD(wp), (HMENU)lp);
   } else if (msg==WM_ENTERIDLE) {
      OnEnterIdle(wp, (HWND)lp);
   }
   return CSubclassWnd::WindowProc(msg, wp, lp);
}

//////////////////
// Got WM_MENUSELECT: show tip.
//
void CMenuTipManager::OnMenuSelect(UINT nItemID, UINT nFlags, 
    HMENU hMenu)
{
   if (!m_wndTip.m_hWnd) {
      m_wndTip.Create(CPoint(0,0), CWnd::FromHandle(m_hWnd));
      m_wndTip.m_szMargins = CSize(4,0);
   }

   if ((nFlags & 0xFFFF)==0xFFFF) {
      m_wndTip.Cancel();   // cancel/hide tip
      m_bMouseSelect = FALSE;
      m_bSticky = FALSE;

   } else if (nFlags & MF_POPUP) {
      m_wndTip.Cancel();   // new popup: cancel
      m_bSticky = FALSE;

   } else if (nFlags & MF_SEPARATOR) {
      // separator: hide tip but remember sticky state
      m_bSticky = m_wndTip.IsWindowVisible();
      m_wndTip.Cancel();

   } else if (nItemID && hMenu) {
      // if tips already displayed, keep displayed
      m_bSticky = m_wndTip.IsWindowVisible() || m_bSticky;

      // remember if mouse used to invoke menu
      m_bMouseSelect = (nFlags & MF_MOUSESELECT)!=0;

      // get prompt and display tip (with or without timeout)
      CString prompt = OnGetCommandPrompt(nItemID);
      if (prompt.IsEmpty())
         m_wndTip.Cancel(); // no prompt: cancel tip

      else {
         CRect rc = GetMenuTipRect(hMenu, nItemID);
         m_wndTip.SetWindowPos(&CWnd::wndTopMost, rc.left, 
            rc.top, rc.Width(), rc.Height(), SWP_NOACTIVATE);
         m_wndTip.SetWindowText(prompt);
         m_wndTip.ShowDelayed(m_bSticky ? 0 : m_iDelay);
      }
   }
}

//////////////////
// Calculate position of tip: next to menu item.
//
CRect CMenuTipManager::GetMenuTipRect(HMENU hmenu, UINT nID)
{
   CWnd* pWndMenu = GetRunningMenuWnd(); //CWnd::WindowFromPoint(pt);
   ASSERT(pWndMenu);
   CRect rcMenu;
   pWndMenu->GetWindowRect(rcMenu); // whole menu rect

   // add heights of menu items until i reach nID
   int count = ::GetMenuItemCount(hmenu);
   int cy = rcMenu.top + GetSystemMetrics(SM_CYEDGE)+1;
   for (int i=0; i<count; i++) {
      CRect rc;
      ::GetMenuItemRect(m_hWnd, hmenu, i, &rc);
      if (::GetMenuItemID(hmenu,i)==nID) {
         // found menu item: adjust rectangle to right and down
         rc += CPoint(rcMenu.right - rc.left, cy - rc.top);
         return rc;
      }
      cy += rc.Height(); // add height
   }
   return CRect(0,0,0,0);
}

//////////////////
// Note that windows are enumerated in top-down Z-order, so the 
// menu window should always be the first one found.
//
static BOOL CALLBACK MyEnumProc(HWND hwnd, LPARAM lParam)
{
   char buf[16];
   GetClassName(hwnd,buf,sizeof(buf));
   if (strcmp(buf,"#32768")==0) { // special classname for 
                                  // menus
      *((HWND*)lParam) = hwnd;    // found it
      return FALSE;
   }
   return TRUE;
}

//////////////////
// Get running menu window.
//
CWnd* CMenuTipManager::GetRunningMenuWnd()
{
   HWND hwnd = NULL;
   EnumWindows(MyEnumProc,(LPARAM)&hwnd);
   return CWnd::FromHandle(hwnd);
}

//////////////////
// Need to handle WM_ENTERIDLE to cancel the tip if the user 
// moved the mouse off the popup menu. For main menus, Windows 
// will send a WM_MENUSELECT message for the parent menu when 
// this happens, but for context menus there's no other way to 
// know the user moved the mouse off the menu.
//
void CMenuTipManager::OnEnterIdle(WPARAM nWhy, HWND hwndWho)
{
   if (m_bMouseSelect && nWhy==MSGF_MENU) {
      CPoint pt;
      GetCursorPos(&pt);
      if (hwndWho != ::WindowFromPoint(pt)) {
         m_wndTip.Cancel();
      }
   }
}

CMenuTipManager is mostly straightforward, but as usual in Windows there are several sticky points that require attention. First are the tooltips themselves: has anyone ever figured out how to use the standard Windows tooltips? Instead, I used the CPopupText class from my September 2000 and June 2001 columns. CPopupText is so simple even a Visual Basic® expert could implement it—if he knew how to type semicolons. All you have to do is instantiate a CPopupText object, call Create and SetWindowText, then CPopupText::ShowDelayed to display the tip in a specified number of milliseconds. CPopupText::Cancel kills the tip. The only hard part is making CPopupText look the same as a normal tooltip. For this, CPopupText uses the menu font and GetSysColor(COLOR_INFOBK), which obtains the (usually pale yellow) system tooltip color. As always, you can download the source code from the link at the top of this article.

The trickiest part of CMenuTipManager is figuring out where to place the tip so it aligns perfectly next to the menu, just to the right of the highlighted item, as shown in Figure 1. The basic idea is to find the location of the menu and do a bunch of arithmetic to add up the heights of all the menu items until you reach the selected menu item. But how do you get the menu's location? This turns out to be a nontrivial problem. As you might guess, the menu is itself a window—but there's no API function to get its handle, so what to do? As I've said many times before, in Windows there's always a way. You simply must not be deterred.

CMenuTipManager has a static helper function, CMenuTipManager::GetRunningMenuWnd, that returns the current running menu window. This function is so useful I decided to make it public. But how does GetRunningMenuWnd work? You might think of calling WindowFromPoint to get the window under the mouse. This would work except for a slight problem: the user can invoke the menu through the keyboard, not the mouse, in which case the cursor could be anywhere, not necessarily over the menu. So instead CMenuTipManager calls ::EnumWindows to enumerate all top-level windows, looking for one with the special class name "#32768" that Windows uses for menus:

static BOOL MyEnumProc(HWND hwnd, LPARAM lParam)
{
   char buf[16];
   GetClassName(hwnd, buf, sizeof(buf));
   if (strcmp(buf,"#32768")==0) { // menu window
      // save hwnd
      return FALSE; // no need to look further
   }
   return TRUE;       // keep looking
}

Since there should be only one menu displayed, the first one MyEnumProc finds is good. Even if for some bizarre reason there were two menus floating around, EnumWindows enumerates the windows in top-down Z-order, so the first one found must be the one that's active. Pretty clever, eh? Once you have the menu window (HWND or CWnd), it's only a matter of pixel math to calculate the exact screen position for the popup tip. CMenuTipManager::OnMenuSelect in Figure 2 shows the details.

What about the tip text? CMenuTipManager provides another helper function, CMenuTipManager::GetMessageString, to get the menu prompt associated with each command. I copied this more or less verbatim from CFrameWnd::GetMessageString. Why duplicate this function? So you can call it without a CFrameWnd. CFrameWnd::GetMessageString should've been static, but whichever friendly Redmondtonian wrote this function apparently didn't notice that CFrameWnd isn't required. And why should you need a frame window to load a resource string? To set the universe straight, I made my version static.

Another bugaboo I discovered when I first implemented menu tips has to do with canceling tips when the user moves the mouse off a menu. For main window menus, if the user moves the mouse away from the menu, Windows sends a WM_MENUSELECT with the parent menu as the handle and the MF_POPUP flag, so it's possible to tell this has happened and hide the tip. For context menus, however, you're out of luck. When the user moves the mouse off a context menu, there's no WM_MENUSELECT message to notify you. Sigh.

No problem; in Windows there's always a way. In this case it turns out that Windows sends a different message: WM_ENTERIDLE. Actually, Windows sends WM_ENTERIDLE any time the program is waiting for input and a dialog or menu is displayed. Windows is even polite enough to pass along the HWND of the dialog or menu—amazing! So all you have to do is compare this HWND to the HWND of the window under the mouse when you get WM_ENTERIDLE. If the HWND of the window under the mouse is the same as the WM_ENTERIDLE HWND, then the mouse is still over the menu; if the HWND of the window under the mouse is some other HWND, the user moved the mouse off the context menu and it's time to cancel the tip. CMenuTipManager::OnEnterIdle in Figure 2 is the function that does it.

Finally, CMenuTipManager uses an internal flag, m_bSticky, to control whether tips are delayed or immediate. When the user first invokes a menu item, you want to wait before showing the tip. But once the first tip appears, you don't want to make the user wait again every time he selects a new menu item. So once a tip is displayed, CMenuTipManager sets m_bSticky = TRUE so it knows to display subsequent tips immediately. Canceling the menu or invoking a command resets m_bSticky to FALSE.

CMenuTipManager displays the same menu prompt whether the menu item is enabled or disabled. To implement the feature in your program that explains why a menu item is disabled, you'll have to modify CMenuTipManager and MFC's prompt mechanism. MFC expects command strings to have the format "Long command prompt explanation\nshort prompt." That is, MFC expects a newline character (\n) separating the long form of the prompt from the short one. MFC displays the long prompt in the status bar and the short prompt in toolbars. You could extend this convention to add a third string used to explain why a command is disabled. You'll have to modify CMenuTipManager::GetResCommandPrompt to take a second argument: which command prompt to get (long/short/disabled), and modify OnGetCommandPrompt to get the disabled prompt if the menu item has the MF_DISABLED flag. I leave this as an exercise for the reader.

Happy programming!

Send your questions and comments for Paul to  cppqa@microsoft.com.

Paul DiLascia is a freelance writer, consultant, and Web/UI designer-at-large. He is the author of Windows++: Writing Reusable Windows Code in C++ (Addison-Wesley, 1992). Paul can be reached at https://www.dilascia.com.